use std::collections::BTreeMap;
use std::fmt::Write as _;
use std::fs;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
use anyhow::{Context, Result};
// Safe for text and double-quoted attribute contexts.
use html_escape::encode_double_quoted_attribute as escape;
use percent_encoding::{AsciiSet, NON_ALPHANUMERIC, PercentEncode, percent_encode};
use crate::catalog::Repository;
use crate::highlight::{Cache as GrammarCache, Highlighter};
/// Blobs larger than this get a raw download instead of a rendered page.
const MAX_RENDER_BYTES: u64 = 1 << 20;
/// Cooperative cancellation for background builds; set when a
/// human-triggered build wants the machine instead.
pub(crate) type BuildCancel = Arc;
pub(crate) fn bail_if_cancelled(cancel: &Option) -> Result<()> {
if cancel
.as_ref()
.is_some_and(|flag| flag.load(Ordering::Relaxed))
{
anyhow::bail!("site build cancelled");
}
Ok(())
}
const PATH: &AsciiSet = &NON_ALPHANUMERIC
.remove(b'-')
.remove(b'.')
.remove(b'_')
.remove(b'~')
.remove(b'/');
/// Percent-encodes a slash-separated path for use in a URI.
pub(crate) fn encode_path(path: &str) -> PercentEncode<'_> {
percent_encode(path.as_bytes(), PATH)
}
/// `tip` names the page's ref and the commit it points at; the client reads
/// them from `` to resolve hash routes relative to
/// the page.
fn page(
instance_name: &str,
title: &str,
description: &str,
tip: Option<(&str, gix::ObjectId)>,
body: &str,
) -> String {
format!(
"\n\
\n\
\n\
\n\
\n\
{title}\n\
\n\
\n\
\n\
\n\
\n\
\n\
\n\
\n\
\n\n{noscript}{body}\n\n\
\n",
instance_name = escape(instance_name),
title = escape(title),
description = escape(description),
format_version = crate::generate::OUTPUT_FORMAT_VERSION,
tip = tip
.map(|(name, oid)| format!(" data-ref=\"{}\" data-tip=\"{oid}\"", escape(name)))
.unwrap_or_default(),
// only repo pages lose anything to a missing client
noscript = if tip.is_some() { NOSCRIPT } else { "" },
)
}
const NOSCRIPT: &str = r#"
"#;
pub(crate) fn catalog_index(instance_name: &str, repositories: &[Repository]) -> String {
let mut body = String::from("
repositories
\n");
if repositories.is_empty() {
body.push_str("
no repositories found
\n");
}
for repos in repositories.chunk_by(|a, b| a.user == b.user) {
let user = &repos[0].user;
let _ = writeln!(
body,
"
\n", escape(&self.name));
if let Some(desc) = &self.description {
let _ = writeln!(html, "
{}
", escape(desc));
}
html.push_str("
\n");
if let Some(url) = &self.clone_url {
let _ = writeln!(html, "
git clone {}
", escape(url));
}
html.push_str("\n");
html
}
}
/// Author, summary, short sha and date of one commit, as a status line.
fn commit_panel(repo: &gix::Repository, oid: gix::ObjectId) -> String {
let Ok(commit) = repo.find_object(oid).map(|o| o.try_into_commit()) else {
return String::new();
};
let Ok(commit) = commit else {
return String::new();
};
let summary = commit.message().map(|m| m.summary().to_string()).unwrap_or_default();
let author = commit.author().map(|a| a.name.to_string()).unwrap_or_default();
format!(
"
\
{author}\
{summary}\
{short}\
{time}
\n",
author = escape(&author),
summary = escape(&summary),
short = commit.id().shorten_or_id(),
time = time_of(&commit),
)
}
pub struct RefTip {
pub kind: RefKind,
pub name: String,
pub commit_id: gix::ObjectId,
pub static_mode: Option,
}
/// All local branches and tags, peeled to commits. Refs that don't peel to a
/// commit (e.g. tags of blobs) are skipped.
pub fn list_refs(repo: &gix::Repository) -> Result> {
let platform = repo.references()?;
let mut tips: Vec = Vec::new();
for (kind, iter) in [
(RefKind::Branch, platform.local_branches()?),
(RefKind::Tag, platform.tags()?),
] {
for r in iter {
let mut r = r.map_err(|e| anyhow::anyhow!("failed to iterate refs: {e}"))?;
let name = r.name().shorten().to_string();
// A branch and tag may share a short name. The flat /ref/
// namespace gives the branch precedence.
if kind == RefKind::Tag
&& tips.iter().any(|t| t.kind == RefKind::Branch && t.name == name)
{
continue;
}
if let Ok(commit) = r.peel_to_commit() {
tips.push(RefTip {
kind,
name,
commit_id: commit.id,
static_mode: None,
});
}
}
}
Ok(tips)
}
/// The branch HEAD points at, if it exists among `tips` (falling back to the
/// first branch, matching forge behaviour for detached/unborn HEADs).
pub fn head_tip<'a>(repo: &gix::Repository, tips: &'a [RefTip]) -> Option<&'a RefTip> {
let head = repo.head_name().ok().flatten().map(|n| n.shorten().to_string());
tips.iter()
.find(|t| t.kind == RefKind::Branch && Some(&t.name) == head.as_ref())
.or_else(|| tips.iter().find(|t| t.kind == RefKind::Branch))
}
pub fn write_file(path: &Path, contents: impl AsRef<[u8]>) -> Result<()> {
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)?;
}
fs::write(path, contents).with_context(|| format!("writing {}", path.display()))
}
/// Renders the full static view of one ref (tree pages, blob pages, raw files
/// for binaries) into `out`, which is typically a staging dir later swapped
/// into place at `/ref//`.
pub fn render_ref(
site: &Site,
tips: &[RefTip],
tip: &RefTip,
commit: &gix::Commit,
out: &Path,
reuse: Option>,
highlights: Arc,
cancel: Option,
) -> Result<()> {
let mut r = RefRenderer::new(site, tips, tip, commit, out, highlights.clone(), cancel.clone());
let tree = commit.tree()?;
let mut blobs = Vec::new();
r.walk(&tree, &mut Vec::new(), &mut blobs)?;
// Blob pages dominate build time and are independent of each other, so
// fan out; the repository is only ever read, so each worker gets its own
// thread-local handle and reusable highlighter. Page costs vary wildly
// (and cluster by directory), so workers pull from a shared counter
// rather than taking fixed shares.
let ctx = BlobContext {
instance_name: site.instance_name.clone(),
name: site.name.clone(),
base_url: site.base_url.clone(),
urlbase: r.urlbase.clone(),
repo_header: site.repo_header(),
label: tip.name.clone(),
tip_id: commit.id,
highlight: tip.static_mode != Some(StaticMode::Plain),
out: out.to_owned(),
reuse,
highlights,
cancel,
};
let repo = site.repo.clone().into_sync();
let next = AtomicUsize::new(0);
rayon::broadcast(|_| {
let repo = repo.to_thread_local();
let mut highlighter = Highlighter::new(ctx.highlights.clone());
while let Some(job) = blobs.get(next.fetch_add(1, Ordering::Relaxed)) {
if let Err(error) = bail_if_cancelled(&ctx.cancel)
.and_then(|()| blob_page(&ctx, &repo, &mut highlighter, job))
{
next.store(blobs.len(), Ordering::Relaxed);
return Err(error);
}
}
Ok(())
})
.into_iter()
.collect()
}
/// Everything a blob page needs besides the per-worker repository handle and
/// highlighter.
struct BlobContext<'a> {
instance_name: String,
name: String,
base_url: String,
urlbase: String,
repo_header: String,
label: String,
tip_id: gix::ObjectId,
highlight: bool,
out: PathBuf,
reuse: Option>,
highlights: Arc,
cancel: Option,
}
struct BlobJob {
path: Vec,
oid: gix::ObjectId,
is_link: bool,
/// Switcher and crumbs, prebuilt during the walk: they need ref-wide
/// state that the workers don't carry.
chrome: String,
}
fn blob_page(
ctx: &BlobContext,
repo: &gix::Repository,
hl: &mut Highlighter,
job: &BlobJob,
) -> Result<()> {
let joined = job.path.join("/");
let rel = format!("blob/{joined}");
// Embedded images may change without the document blob changing.
if !is_markdown(&joined)
&& !is_typst(&joined)
&& let Some(reuse) = ctx.reuse
&& let Some((previous, previous_versions)) = reuse.previous
&& reuse_blob(
previous,
previous_versions,
reuse.versions,
&joined,
&ctx.out,
)
{
return Ok(());
}
let data = &repo.find_object(job.oid)?.data.to_vec();
let size = data.len() as u64;
let image = !job.is_link && image_mime(&joined).is_some();
let raw_url = format!("{}raw/{}/{}", ctx.base_url, job.oid, encode_path(&joined));
let looks_binary = data[..data.len().min(8192)].contains(&0);
let text = (!job.is_link && !image && !looks_binary && size <= MAX_RENDER_BYTES)
.then(|| String::from_utf8_lossy(data));
let lines = text.as_ref().map_or(0, |text| text.lines().count());
let stats = if job.is_link {
format!("symlink \u{2192} {}", escape(&String::from_utf8_lossy(data)))
} else if text.is_some() {
format!(
"{}{lines} line{}",
human_size(size),
if lines == 1 { "" } else { "s" },
)
} else {
format!(
"{}{}",
if image {
"image"
} else if looks_binary {
"binary file"
} else {
"large file"
},
human_size(size),
)
};
let touched = ctx
.reuse
.and_then(|reuse| reuse.versions.get(&joined))
.and_then(|version| gix::ObjectId::from_hex(version.touched.as_bytes()).ok())
.unwrap_or(ctx.tip_id);
let mut body = format!(
"{repo_header}{panel}{chrome}{stats}\
raw\n",
repo_header = ctx.repo_header,
panel = commit_panel(repo, touched),
chrome = job.chrome,
);
if let Some(text) = text {
let name = job.path.last().expect("blob path is never empty");
// Plain mode defers highlighting to the client, which detects the
// language from the path carried in data-hl.
let (src_attrs, code) = if ctx.highlight {
let lang = crate::grammar_manifest::detect(name);
let code = lang
.and_then(|lang| hl.highlight_lines(lang, &text).ok())
.unwrap_or_else(|| text.lines().map(|line| escape(line).to_string()).collect());
let class = lang.map(|l| format!(" language-{l}")).unwrap_or_default();
(format!("class=\"code src{class}\""), code)
} else {
(
format!("class=\"code src\" data-hl=\"{}\"", escape(&joined)),
text.lines().map(|line| escape(line).to_string()).collect(),
)
};
let mut source = format!("
");
let line_count = code.len();
for (i, line) in code.into_iter().enumerate() {
let number = i + 1;
let _ = write!(
source,
"{number}{line}"
);
if number < line_count {
source.push_str("\n");
}
source.push_str("");
}
source.push_str("
");
if is_markdown(name) {
let urls = RelativeUrls {
repo,
base_url: &ctx.base_url,
urlbase: &ctx.urlbase,
commit: ctx.tip_id,
directory: &job.path[..job.path.len() - 1],
};
let rendered = markdown(hl, &text, |url, image| urls.resolve(url, image));
let _ = writeln!(
body,
"
\
\
\
\
\
{rendered}\
{source}
\
",
);
} else if is_typst(name) {
let urls = RelativeUrls {
repo,
base_url: &ctx.base_url,
urlbase: &ctx.urlbase,
commit: ctx.tip_id,
directory: &job.path[..job.path.len() - 1],
};
let rendered = typst(hl, &text, |url, image| urls.resolve(url, image));
let _ = writeln!(
body,
"
\
\
\
\
\
{rendered}\
{source}
\
",
);
} else {
let _ = writeln!(body, "{source}");
}
} else if image {
let _ = writeln!(
body,
"
",
escape(&raw_url),
escape(&joined)
);
} else if !job.is_link {
let _ = writeln!(
body,
"
",
escape(&raw_url),
escape(job.path.last().expect("blob path is never empty"))
);
}
let title = format!("{} - {} @ {}", joined, ctx.name, ctx.label);
let description = format!("{joined} in {} at {}.", ctx.name, ctx.label);
write_file(
&ctx.out.join(&rel),
page(&ctx.instance_name, &title, &description, Some((&ctx.label, ctx.tip_id)), &body),
)
}
fn reuse_blob(
previous: &Path,
previous_versions: &BTreeMap,
versions: &BTreeMap,
path: &str,
out: &Path,
) -> bool {
let Some(version) = versions.get(path) else {
return false;
};
if previous_versions.get(path) != Some(version) {
return false;
}
let previous = previous.join("blob").join(path);
let out = out.join("blob").join(path);
if !previous.is_file()
|| out.parent().is_none_or(|parent| fs::create_dir_all(parent).is_err())
{
return false;
}
if fs::hard_link(&previous, &out).is_ok() || fs::copy(&previous, &out).is_ok() {
return true;
}
let _ = fs::remove_file(out);
false
}
/// The landing page: repo header plus the HEAD branch's root tree view, with
/// entry links pointing into the branch's own pages under `ref/`.
pub fn render_site_index(
site: &Site,
tips: &[RefTip],
out: &Path,
highlights: Arc,
cancel: Option,
) -> Result<()> {
let mut body = format!("← back\n{}", site.repo_header());
let head = head_tip(site.repo, tips);
match head {
None => body.push_str("
no branches yet
\n"),
Some(tip) => {
let commit = site.repo.find_object(tip.commit_id)?.try_into_commit()?;
let tree = commit.tree()?;
let mut r = RefRenderer::new(site, tips, tip, &commit, out, highlights, cancel);
let entries = collect_entries(&tree)?;
body.push_str(&commit_panel(site.repo, tip.commit_id));
body.push_str(&r.tree_topbar(&[], &entries));
if tip.static_mode == Some(StaticMode::Highlighted) {
body.push_str(&render_languages(
&crate::languages::analyze(site.repo, &tree)?,
tip.commit_id,
));
}
body.push_str(&r.overview(&entries));
body.push_str(&r.readme_section(&[], &entries));
}
}
let description = site
.description
.clone()
.unwrap_or_else(|| format!("{} repository on {}.", site.name, site.instance_name));
write_file(
&out.join("index.html"),
page(&site.instance_name, &site.name, &description, head.map(|tip| (tip.name.as_str(), tip.commit_id)), &body),
)
}
/// `data-tip` and `data-language` let the client turn each label into a
/// link to that language's files at the tip.
fn render_languages(stats: &[crate::languages::Stat], tip: gix::ObjectId) -> String {
let total = stats.iter().map(|stat| stat.bytes).sum::();
if total == 0 {
return String::new();
}
let mut html = format!(
"\nlanguages",
);
for stat in stats {
let percentage = stat.bytes as f64 * 100.0 / total as f64;
let _ = write!(
html,
"",
escape(stat.name),
percentage,
stat.color,
);
}
html.push_str("\n
\n");
for stat in stats {
let percentage = stat.bytes as f64 * 100.0 / total as f64;
let _ = writeln!(
html,
"
",
href = site.ref_href(tip),
name = escape(&tip.name),
summary = escape(&summary),
time = time,
);
}
body.push_str("
\n");
}
let title = format!("refs - {}", site.name);
let description = format!("Branches and tags for {}.", site.name);
write_file(
&out.join("refs"),
page(&site.instance_name, &title, &description, head.map(|tip| (tip.name.as_str(), tip.commit_id)), &body),
)
}
struct RefRenderer<'a> {
site: &'a Site<'a>,
tips: &'a [RefTip],
out: &'a Path,
label: String,
/// Absolute URL prefix of this ref's pages, e.g. `/user/repo/ref/main/`.
urlbase: String,
tip_id: gix::ObjectId,
hl: Highlighter,
cancel: Option,
}
enum EntryKind {
Dir,
File,
Link,
Submodule,
}
type Entry = (String, EntryKind, gix::ObjectId);
fn collect_entries(tree: &gix::Tree<'_>) -> Result> {
let mut entries = Vec::new();
for entry in tree.iter() {
let entry = entry?;
let mode = entry.mode();
let kind = if mode.is_tree() {
EntryKind::Dir
} else if mode.is_link() {
EntryKind::Link
} else if mode.is_commit() {
EntryKind::Submodule
} else {
EntryKind::File
};
entries.push((entry.filename().to_string(), kind, entry.oid().to_owned()));
}
entries.sort_by(|a, b| {
let rank = |k: &EntryKind| !matches!(k, EntryKind::Dir);
rank(&a.1).cmp(&rank(&b.1)).then_with(|| a.0.cmp(&b.0))
});
Ok(entries)
}
/// `a/b/c` when `a` holds only `b`, which holds only `c`: a chain of lone
/// directories reads better as one link than as three clicks.
fn collapse_lone_dirs(repo: &gix::Repository, name: &str, mut oid: gix::ObjectId) -> String {
let mut path = name.to_string();
while let Ok(tree) = repo.find_tree(oid) {
let mut entries = tree.iter();
match (entries.next(), entries.next()) {
(Some(Ok(only)), None) if only.mode().is_tree() => {
let _ = write!(path, "/{}", only.filename());
oid = only.oid().to_owned();
}
_ => break,
}
}
path
}
impl<'a> RefRenderer<'a> {
fn new(
site: &'a Site<'a>,
tips: &'a [RefTip],
tip: &RefTip,
commit: &gix::Commit,
out: &'a Path,
highlights: Arc,
cancel: Option,
) -> Self {
RefRenderer {
site,
tips,
out,
label: tip.name.clone(),
urlbase: format!("{}ref/{}/", site.base_url, encode_path(&tip.name)),
tip_id: commit.id,
hl: Highlighter::new(highlights),
cancel,
}
}
fn walk(
&mut self,
tree: &gix::Tree<'_>,
path: &mut Vec,
blobs: &mut Vec,
) -> Result<()> {
bail_if_cancelled(&self.cancel)?;
let entries = collect_entries(tree)?;
self.tree_page(path, &entries)?;
for (name, kind, oid) in entries {
path.push(name);
match kind {
EntryKind::Dir => {
let subtree = self.site.repo.find_object(oid)?.try_into_tree()?;
self.walk(&subtree, path, blobs)?;
}
EntryKind::File | EntryKind::Link => {
blobs.push(BlobJob {
chrome: format!("{}{}", self.switcher(), self.crumbs(path, true)),
path: path.clone(),
oid,
is_link: matches!(kind, EntryKind::Link),
});
}
EntryKind::Submodule => {}
}
path.pop();
}
Ok(())
}
fn tree_page(&mut self, path: &[String], entries: &[Entry]) -> Result<()> {
let rel = if path.is_empty() {
"index.html".to_string()
} else {
format!("tree/{}/index.html", path.join("/"))
};
let mut body = if path.is_empty() {
"← back\n".to_string()
} else {
String::new()
};
body.push_str(&self.site.repo_header());
body.push_str(&commit_panel(self.site.repo, self.tip_id));
body.push_str(&self.tree_topbar(path, entries));
if path.is_empty() {
body.push_str(&self.overview(entries));
} else {
body.push_str("
files
\n");
body.push_str(&self.listing(path, entries));
}
body.push_str(&self.readme_section(path, entries));
let title = if path.is_empty() {
format!("{} @ {}", self.site.name, self.label)
} else {
format!("{}/ - {} @ {}", path.join("/"), self.site.name, self.label)
};
let description = if path.is_empty() {
format!("Source tree for {} at {}.", self.site.name, self.label)
} else {
format!(
"{} in {} at {}.",
path.join("/"),
self.site.name,
self.label,
)
};
write_file(
&self.out.join(&rel),
page(&self.site.instance_name, &title, &description, Some((&self.label, self.tip_id)), &body),
)
}
/// Root-page layout: file listing beside a recent-commits log.
fn overview(&self, entries: &[Entry]) -> String {
format!(
"
files
\n{}
\n",
self.listing(&[], entries),
self.log_section(),
)
}
/// Recent commits from this ref's tip, jj-style: change id (from the
/// `change-id` commit header jj can write), short sha, summary.
fn log_section(&self) -> String {
const SHOWN: usize = 10;
let mut s = String::from("
recent commits
\n\n");
let mut shown = Vec::with_capacity(SHOWN);
let mut parents = Vec::new();
let mut truncated = false;
let Ok(walk) = self.site.repo.rev_walk([self.tip_id]).all() else {
return String::new();
};
for (n, info) in walk.flatten().enumerate() {
if n == SHOWN {
truncated = true;
break;
}
let Ok(commit) = self
.site
.repo
.find_object(info.id)
.map_err(anyhow::Error::from)
.and_then(|o| Ok(o.try_into_commit()?))
else {
continue;
};
shown.push(info.id);
parents.extend(commit.parent_ids().map(|id| id.detach()));
let change_id = commit
.decode()
.ok()
.and_then(|c| c.extra_headers().find("change-id").map(|v| v.to_string()));
let cid = change_id
.as_deref()
.map(|c| {
format!(
"{} ",
info.id,
escape(&c[..c.len().min(8)]),
)
})
.unwrap_or_default();
let summary = commit.message().map(|m| m.summary().to_string()).unwrap_or_default();
let author = commit.author().map(|a| a.name.to_string()).unwrap_or_default();
let _ = writeln!(
s,
"
{cid}{sha}{author}{time}{summary}
",
full = info.id,
sha = commit.id().shorten_or_id(),
author = escape(&author),
time = time_of(&commit),
summary = escape(&summary),
);
}
s.push_str("\n");
if truncated {
let mut frontier = Vec::new();
for parent in parents {
if !shown.contains(&parent) && !frontier.contains(&parent) {
frontier.push(parent);
}
}
let frontier = frontier
.iter()
.map(ToString::to_string)
.collect::>()
.join(" ");
let _ = writeln!(
s,
"
⋯ older commits not shown
",
);
}
s
}
fn listing(&self, path: &[String], entries: &[Entry]) -> String {
let mut sub = path.join("/");
if !sub.is_empty() {
sub.push('/');
}
let mut body = "
\n".to_string();
for (name, kind, oid) in entries {
match kind {
EntryKind::Dir => {
let name = collapse_lone_dirs(self.site.repo, name, *oid);
let _ = writeln!(
body,
"
",
base = self.urlbase,
href = encode_path(&format!("{sub}{name}")),
name = escape(name),
sigil = if matches!(kind, EntryKind::Link) { "@" } else { "" },
size = human_size(size),
);
}
EntryKind::Submodule => {
let _ = writeln!(
body,
"
{name} @ {oid:.8}
",
name = escape(name),
);
}
}
}
body.push_str("
\n");
body
}
fn readme_section(&mut self, path: &[String], entries: &[Entry]) -> String {
match find_readme(self.site.repo, entries) {
Some((name, data)) => {
let text = String::from_utf8_lossy(&data);
let readme = if is_markdown(&name) {
let urls = RelativeUrls {
repo: self.site.repo,
base_url: &self.site.base_url,
urlbase: &self.urlbase,
commit: self.tip_id,
directory: path,
};
markdown(&mut self.hl, &text, |url, image| urls.resolve(url, image))
} else if is_typst(&name) {
let urls = RelativeUrls {
repo: self.site.repo,
base_url: &self.site.base_url,
urlbase: &self.urlbase,
commit: self.tip_id,
directory: path,
};
typst(&mut self.hl, &text, |url, image| urls.resolve(url, image))
} else {
format!("
{}
\n", escape(&text))
};
format!("\n{readme}\n")
}
None => String::new(),
}
}
fn tree_topbar(&self, path: &[String], entries: &[Entry]) -> String {
let folders = entries.iter().filter(|(_, kind, _)| matches!(kind, EntryKind::Dir)).count();
let files = entries.len() - folders;
let mut stats = String::from("");
if folders > 0 {
let _ = write!(stats, "{folders} folder{}", if folders == 1 { "" } else { "s" });
}
if files > 0 {
let _ = write!(stats, "{files} file{}", if files == 1 { "" } else { "s" });
}
stats.push_str("");
// the empty actions slot is where the client puts the history link
format!(
"{}{}{stats}\n",
self.switcher(),
self.crumbs(path, false),
)
}
/// A no-JS `` dropdown listing all refs. Baked at build time, so
/// pages of refs untouched since a ref was added/deleted list it stale;
/// the always-rebuilt index and refs pages stay fresh.
fn switcher(&self) -> String {
let mut s = format!(
"{}
",
escape(&self.label),
);
for (kind, heading) in [(RefKind::Branch, "branches"), (RefKind::Tag, "tags")] {
let mut group = self
.tips
.iter()
.filter(|t| t.kind == kind && t.static_mode.is_some())
.peekable();
if group.peek().is_none() {
continue;
}
let _ = write!(s, "{heading}");
for t in group {
let current = t.name == self.label;
let _ = write!(
s,
"{name}",
class = if current { " class=\"current\"" } else { "" },
href = self.site.ref_href(t),
name = escape(&t.name),
);
}
}
let _ = write!(
s,
"all refs →
",
self.site.base_url,
);
s
}
/// Breadcrumb nav: site name / path components, all but the last linked.
fn crumbs(&self, path: &[String], last_is_file: bool) -> String {
let mut nav = format!(
"\n");
nav
}
}
/// Author time as a `
\n");
paragraph = false;
}
if !list_kind.is_empty() && list.is_empty() {
list = list_kind;
let _ = writeln!(out, "<{list}>");
}
if !block && !paragraph && paragraphs {
if matches!(expr, Expr::Space(_)) { continue; }
out.push_str("
");
paragraph = true;
}
match expr {
Expr::Text(text) => out.push_str(&escape(text.get())),
Expr::Space(_) => out.push(' '),
Expr::Parbreak(_) => {},
Expr::Linebreak(_) => out.push_str(" \n"),
Expr::Escape(value) => out.push_str(&escape(&value.get().to_string())),
Expr::Shorthand(value) => out.push_str(&escape(&value.get().to_string())),
Expr::Strong(value) => {
let body = markup_inline(value.body(), hl, resolve);
let _ = write!(out, "{body}");
}
Expr::Emph(value) => {
let body = markup_inline(value.body(), hl, resolve);
let _ = write!(out, "{body}");
}
Expr::Heading(value) => {
let level = value.depth().get().min(6);
let body = markup_inline(value.body(), hl, resolve);
let _ = writeln!(out, "{body}");
}
Expr::ListItem(value) => {
let body = markup(value.body(), hl, resolve, true);
let _ = writeln!(out, "
{body}
");
}
Expr::EnumItem(value) => {
let number = value.number().map(|n| format!(" value=\"{n}\"")).unwrap_or_default();
let body = markup(value.body(), hl, resolve, true);
let _ = writeln!(out, "
{body}
");
}
Expr::TermItem(value) => {
let term = markup_inline(value.term(), hl, resolve);
let body = markup(value.description(), hl, resolve, true);
let _ = writeln!(out, "
{term}
{body}
");
}
Expr::Link(value) => {
let target = value.get().as_str();
let href = resolve(target, false).unwrap_or_else(|| target.to_owned());
let _ = write!(out, "{}", escape(&href), escape(target));
}
Expr::Raw(raw) => {
let code = raw.lines().map(|line| line.get().as_str()).collect::>().join("\n");
if raw.block() {
let lang = raw.lang().and_then(|lang| crate::grammar_manifest::canonical(lang.get()));
let class = lang.map(|lang| format!(" class=\"language-{lang}\"")).unwrap_or_default();
let code = lang.and_then(|lang| hl.highlight(lang, &code).ok()).unwrap_or_else(|| escape(&code).to_string());
let _ = writeln!(out, "
{code}
");
} else {
let _ = write!(out, "{}", escape(&code));
}
}
Expr::FuncCall(call) if image(call).is_some() => {
let (path, alt) = image(call).unwrap();
let url = resolve(&path, true).unwrap_or(path);
let _ = write!(out, "", escape(&url), escape(&alt));
}
Expr::SmartQuote(_) => out.push_str(&escape(&expr.to_untyped().full_text())),
_ => {
let source = expr.to_untyped().full_text();
let prefix = if matches!(expr, Expr::Equation(_) | Expr::Label(_) | Expr::Ref(_)) { "" } else { "#" };
let _ = write!(out, "{prefix}{}", escape(&source));
}
}
}
if paragraph { out.push_str("\n"); }
if !list.is_empty() { let _ = writeln!(out, "{list}>"); }
out
}
fn markup_inline(value: ast::Markup<'_>, hl: &mut Highlighter, resolve: &mut impl FnMut(&str, bool) -> Option) -> String {
markup(value, hl, resolve, false)
}
let tree = parse(src);
if tree.errors_and_warnings().0.is_empty() {
if let Some(root) = tree.cast::() {
return markup(root, hl, &mut resolve, true);
}
}
format!("
{}
\n", escape(src))
}
fn human_size(bytes: u64) -> String {
const UNITS: &[&str] = &["KiB", "MiB", "GiB", "TiB"];
if bytes < 1024 {
return format!("{bytes} B");
}
let mut size = bytes as f64;
let mut unit = "";
for u in UNITS {
size /= 1024.0;
unit = u;
if size < 1024.0 {
break;
}
}
format!("{size:.1} {unit}")
}
#[cfg(test)]
mod tests {
use std::collections::BTreeMap;
use std::fs;
use std::os::unix::fs::symlink;
use anyhow::Result;
use crate::highlight::Highlighter;
use crate::testutil::{TempDir, commit, git, grammar_cache, init_repo};
use super::{
BlobVersion, EntryKind, RefKind, collapse_lone_dirs, collect_entries, encode_path,
find_readme, head_tip, human_size, list_refs, markdown, reuse_blob, time_of, typst,
};
#[test]
fn encodes_url_paths() {
assert_eq!(encode_path("a b/c?d#\u{e9}").to_string(), "a%20b/c%3Fd%23%C3%A9");
}
#[test]
fn human_sizes_switch_units_at_1024() {
assert_eq!(human_size(0), "0 B");
assert_eq!(human_size(1023), "1023 B");
assert_eq!(human_size(1024), "1.0 KiB");
assert_eq!(human_size(1536), "1.5 KiB");
assert_eq!(human_size(1 << 20), "1.0 MiB");
assert_eq!(human_size(1 << 40), "1.0 TiB");
assert_eq!(human_size(1 << 50), "1024.0 TiB");
}
#[test]
fn commit_time_carries_absolute_timestamp() -> Result<()> {
let root = TempDir::new("time-of");
let repo_path = root.join("repo");
init_repo(&repo_path)?;
fs::write(repo_path.join("file"), "")?;
git(&repo_path, &["add", "-A"])?;
git(&repo_path, &["commit", "-qm", "first", "--date=@978307200 +0000"])?;
let repo = gix::open(&repo_path)?;
let commit = repo.head_commit()?;
assert_eq!(
time_of(&commit),
"2001-01-01",
);
Ok(())
}
#[test]
fn tree_entries_list_directories_first_then_by_name() -> Result<()> {
let root = TempDir::new("entries");
let repo_path = root.join("repo");
init_repo(&repo_path)?;
for dir in ["zeta", "alpha"] {
fs::create_dir(repo_path.join(dir))?;
fs::write(repo_path.join(dir).join("keep"), "")?;
}
fs::write(repo_path.join("beta"), "")?;
fs::write(repo_path.join("aardvark"), "")?;
symlink("beta", repo_path.join("gamma"))?;
commit(&repo_path, "first")?;
let repo = gix::open(&repo_path)?;
let entries = collect_entries(&repo.head_commit()?.tree()?)?;
let names: Vec<_> = entries.iter().map(|(name, ..)| name.as_str()).collect();
assert_eq!(names, ["alpha", "zeta", "aardvark", "beta", "gamma"]);
assert!(matches!(entries[1].1, EntryKind::Dir));
assert!(matches!(entries[2].1, EntryKind::File));
assert!(matches!(entries[4].1, EntryKind::Link));
Ok(())
}
#[test]
fn lone_directory_chains_collapse_into_one_name() -> Result<()> {
let root = TempDir::new("collapse");
let repo_path = root.join("repo");
init_repo(&repo_path)?;
fs::create_dir_all(repo_path.join("a/b/c"))?;
fs::write(repo_path.join("a/b/c/keep"), "")?;
fs::create_dir_all(repo_path.join("x/y"))?;
fs::write(repo_path.join("x/y/keep"), "")?;
fs::write(repo_path.join("x/stop"), "")?;
commit(&repo_path, "first")?;
let repo = gix::open(&repo_path)?;
let entries = collect_entries(&repo.head_commit()?.tree()?)?;
let collapsed: Vec<_> = entries
.iter()
.map(|(name, _, oid)| collapse_lone_dirs(&repo, name, *oid))
.collect();
assert_eq!(collapsed, ["a/b/c", "x"]);
Ok(())
}
#[test]
fn readme_lookup_prefers_markdown_ignores_case_and_skips_directories() -> Result<()> {
let root = TempDir::new("readme");
let repo_path = root.join("repo");
init_repo(&repo_path)?;
fs::write(repo_path.join("readme.txt"), "txt")?;
fs::write(repo_path.join("README"), "bare")?;
fs::write(repo_path.join("ReadMe.MD"), "md")?;
commit(&repo_path, "first")?;
let repo = gix::open(&repo_path)?;
let entries = collect_entries(&repo.head_commit()?.tree()?)?;
let (name, data) = find_readme(&repo, &entries).unwrap();
assert_eq!((name.as_str(), data.as_slice()), ("ReadMe.MD", b"md".as_slice()));
fs::remove_file(repo_path.join("ReadMe.MD"))?;
fs::create_dir(repo_path.join("readme.md"))?;
fs::write(repo_path.join("readme.md/keep"), "")?;
commit(&repo_path, "second")?;
let entries = collect_entries(&repo.head_commit()?.tree()?)?;
let (name, data) = find_readme(&repo, &entries).unwrap();
assert_eq!((name.as_str(), data.as_slice()), ("README", b"bare".as_slice()));
Ok(())
}
#[test]
fn refs_skip_non_commit_tags_and_let_branches_shadow_tags() -> Result<()> {
let root = TempDir::new("refs");
let repo_path = root.join("repo");
init_repo(&repo_path)?;
fs::write(repo_path.join("file"), "")?;
commit(&repo_path, "first")?;
git(&repo_path, &["tag", "main"])?;
git(&repo_path, &["tag", "v1"])?;
let blob = git(&repo_path, &["rev-parse", "HEAD:file"])?;
git(&repo_path, &["tag", "blobtag", &blob])?;
let repo = gix::open(&repo_path)?;
let mut tips: Vec<_> = list_refs(&repo)?
.into_iter()
.map(|tip| (tip.kind, tip.name))
.collect();
tips.sort_by(|a, b| a.1.cmp(&b.1));
assert_eq!(tips, [(RefKind::Branch, "main".into()), (RefKind::Tag, "v1".into())]);
Ok(())
}
#[test]
fn head_tip_follows_head_and_falls_back_to_any_branch() -> Result<()> {
let root = TempDir::new("head");
let repo_path = root.join("repo");
init_repo(&repo_path)?;
fs::write(repo_path.join("file"), "")?;
commit(&repo_path, "first")?;
git(&repo_path, &["branch", "aaa"])?;
let repo = gix::open(&repo_path)?;
let tips = list_refs(&repo)?;
assert_eq!(head_tip(&repo, &tips).unwrap().name, "main");
git(&repo_path, &["checkout", "-q", "--detach"])?;
let repo = gix::open(&repo_path)?;
assert_eq!(head_tip(&repo, &tips).unwrap().kind, RefKind::Branch);
git(&repo_path, &["symbolic-ref", "HEAD", "refs/heads/unborn"])?;
let repo = gix::open(&repo_path)?;
assert_eq!(head_tip(&repo, &tips).unwrap().kind, RefKind::Branch);
Ok(())
}
#[test]
fn markdown_never_passes_through_raw_html() {
let mut hl = Highlighter::new(grammar_cache());
let html = markdown(
&mut hl,
"\n\n\
text bold\n\n\
```no-such-lang\n\n```\n",
|_, _| None,
);
assert!(html.contains("<script>"), "{html}");
for forbidden in ["